# encoding: UTF-8
# frozen_string_literal: true

#
# blitz3d-parser-ruby is a parser for the Blitz3D file format.
#
# Copyright 2022 陈睿超 (Chen, Ruichao) <sheep0x.moe@protonmail.com>
#
# This program is released under the GNU General Public License version 2 or
# (at your option) any later version.
#
# You should have received a copy of GPLv2 along with this program; if not,
# visit https://www.gnu.org/licenses/old-licenses/gpl-2.0.html to view the
# license.
#


#
# todo method doc (for source reading + for API)
# todo exception semantics doc
#
# TODO document source code structure (call graph)
#         _foo() are private methods
#         foo() are public APIs
#         public wrappers are kept minimal, they just call _foo()
#         _foo() may call each other but never call public wrappers
#
# For source code readers:
#     |pos| keeps track of the “cursor”, scanning linearly forward
#



module Blitz3D      # {{{

  ######################################## public API ########################################

  class InternalBugError  < ::StandardError; end
  class NonBinaryArgument < ::ArgumentError; end
  class SchemaError       < ::StandardError; end
  class ParseError        < ::RuntimeError;  end # todo better name (sounds like ParserError but really is input error)

  ##
  # take in a whole file's content (String, must be ASCII 8bit)
  # (documentation needed)
  #
  def self.parse_file(binary_data, schema: DEFAULT_SCHEMA, root_type: :BB3D)   # {{{
    self._check_is_binary(binary_data)
    # TODO check schema, fail early

    self._parse_file(schema, root_type, binary_data)
  end     # }}}

  ##
  # Parse chunk at binary_data[start...end_1].  |start| includes header.
  #
  def self.parse_chunk(chunk_type, binary_data, start, end_1, schema: DEFAULT_SCHEMA)    # {{{
    self._check_is_binary(binary_data)
    # TODO more checks

    self._parse_chunk(schema, chunk_type, binary_data, start, end_1)
  end    # }}}

  def self._check_is_binary(data)
    raise NonBinaryArgument, 'parser called with non-binary data' unless data.encoding == Encoding::ASCII_8BIT
  end





  def self._recursive_freeze(obj)   # {{{
    obj.freeze
    case obj
    when Array   then obj.each      { self._recursive_freeze(_1) }
    when Hash    then obj.each_pair { self._recursive_freeze(_1)
                                      self._recursive_freeze(_2) }
    end
  end   # }}}

  ##
  # NodeSchema   = [ [field1, field2, ...],                                     <= static fields
  #                  [field1, field2, ...] | func | nil,                        <= repeated fields
  #                  [subnode_or_subnodes, subnode_or_subnodes, ...] | nil,     <= inner chunks
  #                  func,                                                      <= post-processor
  #                ]
  #              | nil
  #
  #                First list of fields appear just once.  Second list of fields appear 0 or more times, until the chunk is exhausted.
  #                If repeating fields spec is a function, it is called at run time with the non-repeating fields' values as arguments, and returns a list of field specs.  See code for example.
  #                Don't make repeating fields [].  Logically, it causes infinite loops.  (An exception is raised instead.)
  #                Inner chunks and repeating fields are mutually exclusive.  They can both be missing though.
  #
  # NodeSchema can also be nil, which means raise exception on encounter.  This is mainly used for YAGNI.
  #
  # Field        = Type      where Type = :i32 / :f32 / :str0
  #
  #                 i32 = unsigned 32-bit integer (little endian)
  #                 f32 = single precision floating point number (little endian).  Hopefully IEEE 754, but I'm not sure if Ruby and/or Blitz3D guarantee this.
  #                 str0 = 0 or more non-NUL octets, followed by a single NUL(0x00).  a.k.a C-string
  #
  # SubnodesSpec = node_type                    node_type is a Symbol (like :TEXS)
  #              | [:maybe, node_type, ...]     if more than one type, then parse at most one of them (OR-relation)
  #              | [:zero_or_more, node_type]
  #
  # TODO document output format
  #       str0 => binary String  (leave it to user because we don't know which encoding to use)
  #
  # TODO document post-processing
  #
  DEFAULT_SCHEMA = {                                              # {{{
    BB3D: [
              [ :i32 ],
              nil,
              [  [:maybe, :TEXS], [:maybe, :BRUS], [:maybe, :NODE]  ],
              nil,
          ],

    TEXS: [
              [],
              [ :str0, :i32, :i32, :f32,:f32, :f32,:f32, :f32 ],
              nil,
              lambda do |_, textures|
                  textures.map {|file, flags, blend, x,y, xscale,yscale, rot|
                    { file: file, flags: flags, blend: blend, pos: [x,y], scale: [xscale,yscale], rot: rot }
                  }
              end,
          ],

    BRUS: [
              [ :i32 ],
              lambda do |num_textures|  [ :str0,  :f32,:f32,:f32,:f32,  :f32,  :i32, :i32 ]  +  [:i32]*num_textures  end,
              nil,
              lambda do |_, _, brushes|
                  bsnew = brushes.map {|(name, r,g,b,a, shininess, blend, fx, *texture_IDs)|
                    { name: name, color: [r,g,b,a], shininess: shininess, blend: blend, fx: fx, texture_IDs: texture_IDs }
                  }
                  [:brushes, bsnew]
              end,
          ],

    NODE: [
              [ :str0,  :f32,:f32,:f32,  :f32,:f32,:f32,  :f32,:f32,:f32,:f32 ],
              nil,
              [  [:maybe, :MESH, :BONE], [:zero_or_more, :KEYS], [:maybe, :ANIM], [:zero_or_more, :NODE]  ],
              lambda do |_, name, p1,p2,p3, s1,s2,s3, r1,r2,r3,r4, shape, anim_keys, animation, child_nodes|
                  [:node, name,
                   { pos: [p1,p2,p3], scale: [s1,s2,s3], rot: [r1,r2,r3,r4] },
                   shape,
                   anim_keys, animation,
                   child_nodes,
                  ]
              end,
          ],

    MESH: [
              [ :i32 ],
              nil,
              [  :VRTS, :TRIS, [:zero_or_more, :TRIS]  ],
              lambda do |_, brush_ID, vertices, tri0, tris|    [:mesh, brush_ID, vertices, tris.unshift(tri0) ]    end,
          ],

    VRTS: [
              [ :i32, :i32, :i32 ],
              lambda do |flags, n_sets, n_comps|
                  n  = 3
                  n += 3  if flags&1 > 0
                  n += 4  if flags&2 > 0
                  n += n_sets * n_comps
                  [:f32] * n
              end,
              nil,
              lambda do |_, flags, coord_sets, coord_set_size, vertices|
                  vertices.map! {|floats|
                    {
                      pos:    floats.shift(3),
                      norm:  (floats.shift(3) if flags&1 > 0),
                      color: (floats.shift(4) if flags&2 > 0),
                      tex_coords: floats.each_slice(coord_set_size).to_a,
                    }
                  }
                  [:vertices, [coord_sets, coord_set_size], vertices ]
              end,
          ],

    TRIS: [
              [ :i32 ],
              [ :i32,:i32,:i32 ],
              nil,
              nil,
          ],

    BONE: [
              [],
              [ :i32, :f32 ],
              nil,
              nil,
          ],

    KEYS: [
              [ :i32 ],
              lambda do |flags|
                  num_floats = (flags%2) * 3 + (flags/2%2) * 3 + (flags/4%2) * 4
                  [:i32] + [:f32] * num_floats
              end,
              nil,
              lambda do |_, flags, keys|
                  keys.map {|frame, *floats|
                    { frame: frame,
                      pos:   (floats.shift(3) if flags&1 > 0),
                      scale: (floats.shift(3) if flags&2 > 0),
                      rot:   (floats.shift(4) if flags&4 > 0),
                    }
                  }
              end,
          ],

    ANIM: [
              [ :i32, :i32, :f32 ],
              nil,
              nil,
              lambda do |_, flags, num_frames, fps|
                  # unless 0 == flags then warn "Warning: ANIM chunk's flags is not 0" end
                  { flags: flags, num_frames: num_frames, fps: fps }
              end,
          ],
  }                                                     # }}}

  self._recursive_freeze(DEFAULT_SCHEMA)











  ######################################## (currently) private stuffs ########################################

  def self._parse_file(schema, root_type, binary_data)   # {{{
    end_of_chunk, type  = self._parse_generic_chunk(binary_data, 0, binary_data.bytesize)
    unless end_of_chunk == binary_data.bytesize then raise ParseError, 'Corrupt file: root chunk is smaller than file size' end
    unless root_type == type then raise ParseError, "Corrupt file: root chunk type is not #{root_type}" end

    root = self._parse_chunk(schema, root_type, binary_data, 0, end_of_chunk)

    root
  end     # }}}


  ##
  # Look at binary_data[start...end_of_parent] and extract the chunk starting from here
  # |end_of_parent| is not needed for a correctly formatted file.  Only used for sanity checks.
  #
  # returns [end_of_this_chunk, chunk_type]
  # Doesn't return chunk_data_in_binary    because it is implicit in binary_data[start...end_of_this_chunk]
  #
  # Both end_of_parent and end_of_this_chunk are EXCLUSIVE.
  #
  # TODO pick a better name
  #
  def self._parse_generic_chunk(binary_data, start, end_of_parent)    # {{{
    #raise InternalBugError, 'parser called with non-binary data' unless binary_data.encoding == Encoding::ASCII_8BIT
    raise InternalBugError, 'incorrect end of chunk position (details: EOC past EOF)' unless end_of_parent <= binary_data.bytesize
    raise ParseError, 'Corrupt chunk: impossibly small chunk (details: a chunk is < 8 bytes so it cannot even contain a header)' unless end_of_parent - start >= 8
    pos = start
    ck_type    = binary_data[pos, 4]                ; pos += 4
    ck_len     = binary_data[pos, 4].unpack1('L<')  ; pos += 4
    ;                                               ; pos += ck_len
    if pos > end_of_parent then raise ParseError, 'Corrupt chunk: chunk declares a chunk size that extends past the end of parent chunk (or a root chunk extends past EOF)' end

    ck_type = ck_type.force_encoding(Encoding::ASCII)
                     .to_sym
    [pos, ck_type]
  end     # }}}





  def self._parse_chunk(schema, chunk_type, binary_data, start, end_1)    # {{{
    once_fields_spec,
      rep_fields_spec,
      subchunks_spec,
      postprocessor = (schema[chunk_type]   or raise SchemaError, "not yet implemented: #{chunk_type} chunk parsing")

    if rep_fields_spec && subchunks_spec then raise SchemaError, 'repeatable fields and subchunks must not be specified at the same time (leads to ambiguous parse)' end

    pos = start + 8
    result = [chunk_type]

    pos, once_fields  = self._parse_fields(once_fields_spec, binary_data, pos, end_1)
    result.concat  once_fields

    if rep_fields_spec
      if Proc === rep_fields_spec
        rep_fields_spec = rep_fields_spec.call(*once_fields)
      end
      if rep_fields_spec.empty? then raise SchemaError, 'repeatable fields spec is empty (would lead to infinite loop)' end # todo should we report static spec vs generated spec? (so it's less confusing to the user)

      result << self._parse_repeated_fields(rep_fields_spec, binary_data, pos, end_1)
      # pos == end_1 (kind of)

    elsif subchunks_spec
      result.concat  self._parse_subchunks(subchunks_spec, schema, binary_data, pos, end_1)
      # pos == end_1 (kind of)

    else
      if pos != end_1 then raise ParseError, "Corrupt chunk: trailing garbage (inside a #{chunk_type} chunk at #{pos}; chunk area #{start}..#{end_1-1})" end
    end

    result = postprocessor.call(*result) if postprocessor
    result
  end    # }}}


  ##
  # Parsing should end at or before end_of_chunk.  Don't have to end exactly at end_of_chunk.
  # Parsing past end_of_chunk is an error.
  #
  def self._parse_fields(fields_spec, binary_data, start, end_of_chunk)    # {{{
    #raise InternalBugError, 'parser called with non-binary data' unless binary_data.encoding == Encoding::ASCII_8BIT
    raise InternalBugError, 'end_of_chunk > EOF (in _parse_fields())' if end_of_chunk > binary_data.bytesize
    raise InternalBugError, 'start > end_of_chunk (in _parse_fields())' if start > end_of_chunk

    fields = []
    pos = start

    fields_spec.each do |ty|
      # assert pos <= end_of_chunk

      case ty
      when :i32  then    fields << binary_data[pos, 4].unpack1('L<')     ; pos += 4
      when :f32  then    fields << binary_data[pos, 4].unpack1('e' )     ; pos += 4
      when :str0 then s = binary_data.unpack1("@#{pos}Z*") ; fields << s ; pos += s.bytesize + 1
      else               raise InternalBugError, 'unexpected field type (in _parse_fields())'
      end

      if pos > end_of_chunk
        raise ParseError, :str0 == ty ? 'Corrupt chunk: string is not terminated by NUL'
                                      : 'Corrupt chunk: data field(s) extend past end of chunk'
      end
    end

    [pos, fields]
  end  # }}}


  ##
  # Parsing should end at exactly end_of_chunk.  Otherwise, an error.
  #
  def self._parse_repeated_fields(fields_spec, binary_data, start, end_of_chunk)   # {{{
    result = []

    pos = start
    while pos < end_of_chunk
      pos, fields = self._parse_fields(fields_spec, binary_data, pos, end_of_chunk)
      result << fields
    end
    # pos == end_of_chunk

    result
  end     # }}}


  ##
  # Parsing should end at exactly end_1.  Otherwise, an error.
  # XXX pattern matching behavior is unstable. Tested on Ruby 2.7
  #
  def self._parse_subchunks(subchunks_spec, schema, binary_data, start, end_1)     # {{{
    chunks = []        # this is temporary.  will be destructed in-place.
    chunk_types = []   # this too.  this is used for schema verification.

    # Step 1: parse any chunks, don't care what type
    pos = start
    while pos < end_1
      ck_start = pos
      pos, ck_type = self._parse_generic_chunk(binary_data, pos, end_1)
      chunks << self._parse_chunk(schema, ck_type, binary_data, ck_start, pos)
      chunk_types << ck_type
    end
    # pos == end_1

    # Step 2: check if chunk types make sense, and regroup chunks
    result = []
    subchunks_spec.each do |spec|

      case spec
        in Symbol => wanted_type
            ty = chunk_types.shift # could be nil
            if ty != wanted_type then raise ParseError, "InvalidData: expecting #{wanted_type} chunk, found #{ty} chunk" end
            result << chunks.shift

        in [:maybe, *wanted_types]
            result << if wanted_types.include? chunk_types[0]
                        chunk_types.shift
                        chunks.shift
                      end
        in [:zero_or_more, wanted_type]
            result <<( matches = [] )
            while chunk_types[0] == wanted_type
              chunk_types.shift
              matches << chunks.shift
            end

        else  raise InternalBugError, 'unexpected subchunk spec (in _parse_subchunks())'
      end

    end
    chunk_types.empty? or raise ParseError, "InvalidData: bogus chunk(s) (chunk type: #{chunk_types.first})"

    result
  end     # }}}

end     # }}}
